網頁版的權限設好了,接下來試著處理tauri app的權限:
gRPC的後端是使用tonic套件,不像warp是用鐵道的方法,我們只要在中間穿插要加入的中間件就好,處理上比較麻煩:
首先在後端server調整如下:
// web/src/grpc/mod.rs
use tonic::{Request, Status};
use crate::auth::{CurrentUser, key, verify_jwt};
pub fn grpc_route() -> Router {
let greeter = MyGreeter::default();
let tic_tac_toe = TicTacToeGrpcService::default();
Server::builder()
.add_service(GreeterServer::new(greeter))
.add_service(TicTacToeServer::with_interceptor(tic_tac_toe, with_auth))
}
tonic是在新增服務的時候,由原本的new方法,改用另一個with_interceptor
的方法,使用with_interceptor
的話,除了原本服務的物件,還需要加一個中間件的function,這個function用來處理接到request的時候,進行加料使用,這裡的interceptor只能固定格式,不能像warp一樣,不斷加料使用Extract抽出後再進到下一輪,所以在這無法像warp一樣組出基本的積木塊。實作上面的with_auth插件如下:
fn with_auth(mut req: Request<()>) -> Result<Request<()>, Status> {
match req.metadata().get("authorization") {
Some(token) => {
let jwt = token.to_str().unwrap().to_string();
let jwt = jwt.replace("Bearer ", "");
tracing::info!("token: {}", jwt);
let claims = verify_jwt(key(), jwt)?;
let permissions = claims.permissions;
let name = claims.sub;
let user = CurrentUser::User { name, permissions };
req.extensions_mut().insert(user);
},
_ => {
req.extensions_mut()
.insert(CurrentUser::Anonymous);
},
}
Ok(req)
}
這個interceptor中間件,因為前面只有接到request進來,所以也只能對request加料,這邊不同於http request使用的是header,這邊是metadata,我們從request的metadata試著去取得是否有key為authorization的資料,如果有的話,就把它的值作為JWT來解析處理,如果沒有的話,我們就當作是匿名使用者。jwt的解析與rest api中解析的方式一樣,就是把JWT進行驗章後解譯內容。
而這裡如果解析正確,只能繼續把Request往下傳,所以我們額外解析出的CurrentUser物件,就要附加在原本的request上面,這邊使用extensions_mut
方法,添加至request的擴充資料,避免影響到原本request的內容,而這邊的extensions是靠類別去取的。我們繼續往下看:
// web/src/grpc/tic_tac_toe.rs
use crate::auth::CurrentUser;
async fn new_game(
&self, request: Request<EmptyRequest>,
) -> Result<Response<GameSet>, Status> {
let user = request
.extensions()
.get::<CurrentUser>()
.unwrap();
tracing::info!("user: {:?}", user);
// ... 略
}
我們先演示怎麼進行interceptor的處理,暫不實際進行權限檢核,在原本的grpc impl 的 fn 中,我們本來就需要帶入參數 request,而剛剛在interceptor加的料就會帶入到這個request參數中,要使用的話就要用 extensions()
方法叫出擴充資料集,再使用get::<泛型T>()
取得該類別的資料,如果剛剛在interceptor裡有加入這個類別的話,就會解析出來,否則就會傳回None,因為剛剛即便我們沒傳token我一樣會加入CurrentUser::Anonymous(訪客),所以肯會能解析出來,才用unwrap
,如果應用的場景會有可能出現None,就不能用unwrap,不然會造成程式panic中斷。
實測一下,使用前端tauri app呼叫gRPC:
會出現匿名使用者,因為我們還沒幫前端加上request帶token
雖然說是前端,不過這裡主要在講的是tauri的部分(後端的前端)。前端的登入(login)我們一樣用http rest就好,從login取得JWT後,帶入我們後續gRPC的request之中,在此之前,我們需要先實作我們的login,先login的api未實作tauri的部分:
先加入儲存JWT到我們的context物件:
// app/src-tauri/src/context.rs
use std::sync::{Arc, RwLock};
#[derive(Clone, Debug)]
pub struct Context {
// ... 略
pub token: Arc<RwLock<Option<String>>>, // JWT token
}
impl Context {
pub fn load() -> Self {
// ... 略
Self {
// ... 略
token: Arc::new(RwLock::new(None)),
}
}
// ... 略
pub fn token(&self) -> Option<String> { // getter
self.token.read().unwrap().clone()
}
因為這個token基本上只會寫入一次(在剛開啟畫面的時候),後面其實都只會讀取而已,所以用RwLock
,而因為我們的Context是透過tauri的狀態管理manager注入進去的,為了避免跨不同執行緒的處理會有問題,所以這裡需要用Arc包起來,而我們在最後做一個token的getter方便在handler裡要調用時取得token資料。
再來寫tauri的 login handler:
// app/src-tauri/src/auth.rs
use serde::{Deserialize, Serialize};
use tauri::State;
use crate::context::Context;
use crate::error::ErrorResponse;
#[derive(Clone, Deserialize, Serialize)]
pub struct LoginResponse { // 要在main裡註冊使用的,所以要pub
pub access_token: String,
}
#[derive(Serialize)]
struct LoginRequest { // 只在這個mod檔不需要 pub
username: String,
password: String,
}
因為tauri在這裡要承上啟下,取得web端表單輸入的資料進來,再呼叫http request向後端請求,所以這邊需要實作 Request 和 Response 物件,供輸出入使用。
// app/src-tauri/src/auth.rs
#[tauri::command]
pub async fn login(
username: String,
password: String,
mut ctx: State<'_, Context>
) -> Result<LoginResponse, ErrorResponse> {
let url = ctx
.base_url()
.join("login")
.unwrap(); // 網址路徑: /login
let response = ctx
.http_client()
.post(url) // 使用 POST
.json(&LoginRequest { // 帶 JSON Body
username,
password,
})
.send() // 發出請求
.await?;
let jwt: LoginResponse = response // 回應結果
.json() // 解析 JSON Body
.await?;
let mut token = ctx.token // 取得 Context的token
.write() // 取得RwLock的write
.unwrap();
*token = Some(jwt.clone().access_token); // 寫入
Ok(jwt) // 回傳相同物件
}
以上login handler呼叫後端登入API,取得jwt後,存到tauri的AppState中,並把原Response再回傳給前端Svelte,所以前端的前端一樣可以保留其jwt的紀錄。最後到tauri的main中註冊:
@@ app/src-tauri/src/main.rs @@
+mod auth;
+use auth::login;
...
async fn main() -> Result<(), Box<dyn std::error::Error>> {
...
tauri::Builder::default()
...
.invoke_handler(tauri::generate_handler![
...
+ login,
])
接下來到前端調整tauri版的登入API:
// app/src/api/auth.ts
import { invoke } from '@tauri-apps/api/tauri';
export const tauriLogin = async (username: string, password: string): Promise<void> => {
try {
let r: { access_token: string } = await invoke(
'login', {username, password});
const jwt = r.access_token;
setJwt(jwt);
goto('/game').then(() => console.log('redirect to /game'));
} catch (e) {
cleanJwt();
}
}
跟restapi版本差不多,只是改成利用invoke去呼叫tauri裡的command。最後註冊api:
@@ app/src/api/auth.ts @@
+import { login, tauriLogin } from './auth';
-import { login } from './auth';
const tauriApi: Api = {
ticTacToe: ticTacToeApiTauri,
ticTacToeOffline: ticTacToeApiTauriOffline,
+ login: tauriLogin,
- login,
};
完成了login以取得jwt的部分,接下來要實作gRPC的 client 端。
tonic的gRPC client端一樣使用interceptor的概念,用以攔截request,再依需求進行加料。
// app/src-tauri/src/tic_tac_toe/grpc.rs
use tonic::{Request, metadata::MetadataValue};
#[tauri::command]
pub async fn new_game_grpc(ctx: State<'_, Context>)
// ... 略
let channel = ctx.channel();
let token = ctx.token();
let mut client = TicTacToeClient::with_interceptor(channel,
move |mut req: Request<()>| {
if token.is_some() {
let jwt = token.clone().unwrap();
let bearer: MetadataValue<_> =
format!("Bearer {}", jwt)
.parse()
.unwrap();
req.metadata_mut()
.insert("authorization", bearer);
}
Ok(req)
});
// ... 略
在建造gRPC客戶端時,一樣把先前的 ::new
,改為::with_interceptor
,就可以加入要使用的interceptor function,而這裡我們用的是閉包,一樣使用 request的參數,我們在這邊對token進行判斷,如果有值的話,就組成Bearer的格式,再加到metadata裡,這裡和後端的寫法很像,需要呼叫metadata_mut
取得可修改的metadata物件,再使用insert
加入authorization的資料。
這邊的function我一時卡在rust的各種所有權檢核還抽不出去,未來如果找到抽出寫比較好的寫法再作更新。
實測一下:
可以看到當前端呼叫我們剛剛的gRPC 方法時,後端有正確接到jwt並解譯出Current User。
有時候要進行API的測試總是要開UI有點不方便,大家一般會使用swagger之類的工具,只要後端跑起來之後,即便沒有前端,有時候也可以方便調試一下,而rust裡面OpenAPI使用的套件是utoipa。這個套件目前支援大部分的rust web框架,我們就來實作看看。
我們先加入套件:
@@ Cargo.toml @@
[workspace.dependencies]
+utoipa = { version = "4.0" }
+utoipa-rapidoc = { version = "1.0" }
+utoipa-swagger-ui = { version = "4.0" }
@@ web/Cargo.toml @@
[dependencies]
+utoipa = { workspace = true }
+utoipa-rapidoc = { workspace = true }
+utoipa-swagger-ui = { workspace = true }
@@ core/Cargo.toml @@
[dependencies]
+utoipa = { worksapce = true }
utoipa套件在解析Entity或Dto時,有幫我們做好一個巨集ToSchema
,我們只要在我們的Struct結構體或Enum枚舉裡加入ToSchema
即可:
@@ core/src/tic_tac_toe.rs @@
+use utoipa::ToSchema;
...
+#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
-#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Symbol {
O,
X,
}
+#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
-#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Game {
pub cells: [Option<Symbol>; 9],
@@ web/src/error.rs @@
+use utoipa::ToSchema;
...
+#[derive(serde::Serialize, ToSchema)]
-#[derive(serde::Serialize)]
+pub struct AppErrorMessage {
-struct AppErrorMessage {
接著在handler上面寫api文件的內容,因為rust不像C#有reflection的功能,所以無法自己掃描有哪些controller,自動產生API,變成我們要自己多寫一些OpenAPI的內容。我們先從controller開始寫:
// web/src/tic_tac_toe.rs
use my_core::tic_tac_toe::Game;
/// GET /tic_tac_toe/:id
#[utoipa::path(
get,
path = "/tic_tac_toe/{id}",
params(
("id" = u32, description = "遊戲ID")
),
responses(
(status = 200, description = "正確取得遊戲結果", body = Game),
(status = 404, description = "找不到資料", body = AppErrorMessage),
(status = 500, description = "意外的錯誤", body = AppErrorMessage),
)
)]
pub fn games_get(
service: impl TicTacToeService
) -> impl Filter<Extract=(impl warp::Reply, ), Error=Rejection> + Clone {
warp::path!("tic_tac_toe" / usize)
.and(warp::get())
.and(warp::any().map(move || service.clone()))
.and_then(handle_games_get)
}
我們在games_get的api上面寫utoipa::path
的attribute巨集,內容就是我們這個api的內容,比如這個request是GET,路徑path 是"/tic_tac_toe/{id}"
,路由參數使用{ }
包起來,而在參數中可以寫明參數id的類別是u32,產生的swagger就可以讓我們填入,description可以針對該欄位進行描述,而回應的部分,可以依不同回應情境編寫其回碼的狀態碼,說明,以及回傳的body物件長相,我們剛剛幫Game和AppErrorMessage加了ToSchema
的巨集,所以這裡body寫的物件就會參照到剛剛toSchema產出的資料,如果沒寫的話會因為對應不到而報錯。
我們再往下看不同的request方法範例:
/// POST /tic_tac_toe/
#[utoipa::path(
post,
path = "/tic_tac_toe",
responses(
(status = 200, description = "正確開啟遊戲新局", body = Game),
(status = 500, description = "意外的錯誤", body = AppErrorMessage),
),
)]
pub fn games_create(
/// PUT /tic_tac_toe/:id/:num
#[utoipa::path(
put,
path = "/tic_tac_toe/{id}/{step}",
params(
("id" = u32, description = "遊戲ID"),
("step" = u32, description = "九宮格格號")
),
responses(
(status = 200, description = "執行成功", body = Game),
(status = 400, description = "違反遊戲規則", body = AppErrorMessage),
(status = 404, description = "找不到資料", body = AppErrorMessage),
(status = 500, description = "意外的錯誤", body = AppErrorMessage),
)
)]
pub fn games_play(
/// DELETE /tic_tac_toe/:id
#[utoipa::path(
delete,
path = "/tic_tac_toe/{id}",
params(
("id" = u32, description = "遊戲ID")
),
responses(
(status = 204, description = "正確刪除"),
(status = 404, description = "找不到資料", body = AppErrorMessage),
(status = 500, description = "意外的錯誤", body = AppErrorMessage),
)
)]
pub fn games_delete(
接著是重頭戲 OpenAPI 主文的撰寫:
加open_api mod:
@@ web/src/lib.rs @@
+pub mod open_api;
要先造一個ApiDoc結構體,放置產生的OpenAPI文件:
// web/src/open_api.rs
use std::sync::Arc;
use utoipa::{
Modify, OpenApi,
openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}
};
use utoipa_swagger_ui::Config;
use utoipa_rapidoc::RapiDoc;
use warp::{
Filter, Rejection, Reply,
path::{FullPath, Tail},
http::{Response, StatusCode, Uri}
};
#[derive(OpenApi)]
#[openapi(
paths(
crate::tic_tac_toe::games_get,
crate::tic_tac_toe::games_create,
crate::tic_tac_toe::games_play,
crate::tic_tac_toe::games_delete,
),
components(
schemas(
my_core::tic_tac_toe::Game,
my_core::tic_tac_toe::Symbol,
crate::error::AppErrorMessage,
)
),
modifiers(& SecurityAddon),
tags(
(name = "game", description = "TicTacToe Game API"),
),
)]
pub struct ApiDoc;
這裡可以看到我們在API文件中加入了path路徑的設定,就是參照到我們剛剛所寫的那幾個api handler的方法,這些巨集就會去取得剛剛在api裡所寫的說明等內容。而components需要在這邊註冊剛剛加ToSchema的物件,才會生效。modifiers是可以加入授權輔助,輸入api key 或 token讓 OpenAPI幫我們代入。
pub struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.as_mut().unwrap(); // we can unwrap safely since there already is components registered.
components.add_security_scheme(
"Authorization",
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("Authorization"))),
)
}
}
上面是驗證的項目可以依需要使用,比如API key或是我們剛剛使用的JWT token,在使用OpenAPI呼叫時可以自動幫我們帶入授權的Header,我這邊是設定Authorization
,有些人可能用API-KEY會使用api_key
或X-API-KEY
等,只要後端設定好跟前端送的一致就可以。
設定swagger服務:
async fn serve_swagger(
full_path: FullPath,
tail: Tail,
config: Arc<Config<'static>>,
) -> Result<Box<dyn Reply + 'static>, Rejection> {
if full_path.as_str() == "/swagger-ui" {
return Ok(Box::new(warp::redirect::found(Uri::from_static(
"/swagger-ui/",
))));
}
let path = tail.as_str();
match utoipa_swagger_ui::serve(path, config) {
Ok(file) => {
if let Some(file) = file {
Ok(Box::new(
Response::builder()
.header("Content-Type", file.content_type)
.body(file.bytes),
))
} else {
Ok(Box::new(StatusCode::NOT_FOUND))
}
}
Err(error) => Ok(Box::new(
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(error.to_string()),
)),
}
}
加handler
pub fn api_doc_handler() -> impl Filter<Extract=impl Reply, Error=Rejection> + Clone {
let api_doc = warp::path("api-doc.json")
.and(warp::get())
.map(|| warp::reply::json(&ApiDoc::openapi()));
let rapidoc_handler = warp::path("rapidoc")
.and(warp::get())
.map(|| warp::reply::html(RapiDoc::new("/api-doc.json").to_html()));
let config = Arc::new(Config::from("/api-doc.json"));
let swagger_ui = warp::path("swagger-ui")
.and(warp::get())
.and(warp::path::full())
.and(warp::path::tail())
.and(warp::any().map(move || config.clone()))
.and_then(serve_swagger);
api_doc.or(rapidoc_handler).or(swagger_ui)
}
最後在main裡設定剛剛的handler:
// web/src/routers.rs
use crate::open_api::api_doc_handler;
pub fn all_routers(ctx: AppContext)
// ... 略
api_doc_handler()
.or(hello)
// ... 略
最後實測一下:
開啟swagger的頁面
看一下展開PUT API內容
實測一下執行的結果
除了swagger以外,我們剛剛還加了另外一個不同的頁面實作RapiDoc,也是吃同樣的api文件,介面風格不太一樣,可以給大家多一種選擇
本系列專案源始碼放置於 https://github.com/kenstt/demo-app